Server Initial Setup
1. Set Up Ubuntu
First, let's set up and prepare our fresh Ubuntu server. Install these essential tools:
- fzf
- bat
- eza
- ripgrep
- fd
- btop
- htop
- ufw
- fail2ban
- git
- lazygit
- starship
- zsh-autosuggestions
- zsh-syntax-highlighting
To run the setup script:
bash
chmod +x setup-ubuntu.sh
./setup-ubuntu.shsetup-ubuntu.sh:
bash
#!/usr/bin/env bash
set -e
GREEN="\e[32m"
BLUE="\e[34m"
RED="\e[31m"
RESET="\e[0m"
TOTAL=9
STEP=0
progress() {
STEP=$((STEP + 1))
PERCENT=$((STEP * 100 / TOTAL))
FILLED=$STEP
EMPTY=$((TOTAL - FILLED))
printf "\n${BLUE}["
printf "%0.s#" $(seq 1 $FILLED)
printf "%0.s " $(seq 1 $EMPTY)
printf "] %3d%%${RESET} %s\n" "$PERCENT" "$1"
}
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Run with sudo${RESET}"
exit 1
fi
USERNAME="${SUDO_USER:-$(logname)}"
HOME_DIR=$(eval echo "~$USERNAME")
progress "Updating system"
apt update
DEBIAN_FRONTEND=noninteractive apt upgrade -y
progress "Installing packages"
apt install -y \
curl \
wget \
git \
unzip \
zip \
ca-certificates \
gnupg \
lsb-release \
software-properties-common \
apt-transport-https
progress "Installing zsh, tmux, vim, and nvim"
apt install -y zsh tmux vim neovim
progress "Installing Oh My Zsh"
if [ ! -d "$HOME_DIR/.oh-my-zsh" ]; then
sudo -u "$USERNAME" env \
RUNZSH=no \
CHSH=no \
KEEP_ZSHRC=yes \
sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
fi
progress "Installing Docker"
install -m 0755 -d /etc/apt/keyrings
if [ ! -f /etc/apt/keyrings/docker.gpg ]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg |
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
fi
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt update
DEBIAN_FRONTEND=noninteractive apt install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
progress "Configuring Docker"
systemctl enable docker
systemctl start docker
usermod -aG docker "$USERNAME"
mkdir -p "$HOME_DIR/.docker"
chown -R "$USERNAME:$USERNAME" "$HOME_DIR/.docker"
progress "Creating tmux config"
cat > "$HOME_DIR/.tmux.conf" <<EOF
set -g mouse on
setw -g mode-keys vi
set -g history-limit 100000
EOF
chown "$USERNAME:$USERNAME" "$HOME_DIR/.tmux.conf"
progress "Creating vim config"
cat > "$HOME_DIR/.vimrc" <<EOF
syntax on
set number
set relativenumber
set tabstop=4
set shiftwidth=4
set expandtab
set mouse=a
EOF
chown "$USERNAME:$USERNAME" "$HOME_DIR/.vimrc"
progress "Changing default shell"
chsh -s "$(which zsh)" "$USERNAME"
echo
echo -e "${GREEN}======================================"
echo "Setup completed successfully!"
echo "======================================${RESET}"
echo
echo "Please logout and log in again."
echo
echo "Verify these commands work:"
echo "docker --version"
echo "docker compose version"
echo "zsh --version"
echo "tmux -V"
echo "nvim --version"
echo2. Create User
This script creates a user named danial, gives it sudo privileges, sets up SSH access, and disables password prompts for sudo.
To run the script:
bash
chmod +x setup-user.sh
./setup-user.shsetup-user.sh:
bash
#!/usr/bin/env bash
set -e
# NOTE: Change these values!
USERNAME="danial"
PASSWORD="CHANGE_ME"
# Optional: your public key
# PUBLIC_KEY="ssh-ed25519 AAAAC3... replace_with_your_key"
echo "Creating user..."
if id "$USERNAME" &>/dev/null; then
echo "User already exists."
else
useradd -m -s /bin/bash "$USERNAME"
echo "$USERNAME:$PASSWORD" | chpasswd
fi
echo "Installing sudo..."
apt update
apt install -y sudo
echo "Adding user to sudo group..."
usermod -aG sudo "$USERNAME"
echo "Adding user to docker group..."
usermod -aG docker "$USERNAME"
echo "Granting passwordless sudo..."
cat > /etc/sudoers.d/$USERNAME <<EOF
$USERNAME ALL=(ALL) NOPASSWD:ALL
EOF
chmod 440 /etc/sudoers.d/$USERNAME
echo "Setting up SSH..."
mkdir -p /home/$USERNAME/.ssh
chmod 700 /home/$USERNAME/.ssh
if [ -n "$PUBLIC_KEY" ]; then
echo "$PUBLIC_KEY" > /home/$USERNAME/.ssh/authorized_keys
chmod 600 /home/$USERNAME/.ssh/authorized_keys
fi
chown -R $USERNAME:$USERNAME /home/$USERNAME/.ssh
echo "Disabling root SSH login..."
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
echo "Ensuring password authentication remains enabled..."
grep -q "^PasswordAuthentication" /etc/ssh/sshd_config \
&& sed -i 's/^PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config \
|| echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
systemctl restart ssh
echo
echo "===================================="
echo "User $USERNAME created successfully."
echo "===================================="
echo
echo "Login with:"
echo
echo "ssh $USERNAME@your-server-ip"
echoIf you want to connect from a Mac device, follow this:
a. On your MacBook: Create an SSH key (if you don’t already have one)
Run:
bash
ls ~/.sshIf you don’t see id_ed25519 and id_ed25519.pub, generate one:
bash
ssh-keygen -t ed25519 -C "macbook-to-server"Press Enter at all prompts (unless you want to change file locations).
b. Copy your public key to the server
Replace danial and SERVER_IP:
bash
ssh-copy-id danial@SERVER_IPIf ssh-copy-id is missing (classic Apple minimalism moment):
bash
cat ~/.ssh/id_ed25519.pub | ssh danial@SERVER_IP "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"c. Fix permissions on server
SSH is pretty dramatic about file permissions:
bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys3. Make Oh My Zsh the default for your new user
a. Ensure zsh and Oh My Zsh are installed
On the server:
bash
which zshIf empty, install zsh:
bash
sudo apt install -y zshCheck if Oh My Zsh exists:
bash
ls /home/danial/.oh-my-zshIf missing, install it as the user:
bash
sudo -u danial sh -c \
"RUNZSH=no CHSH=no KEEP_ZSHRC=yes \
$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"b. Set zsh as the default shell
Run:
bash
sudo chsh -s $(which zsh) danialCheck:
bash
getent passwd danialYou should see something like:
danial:x:1000:1000:...:/home/danial:/usr/bin/zshIf it still says /bin/bash, nothing changed.
4. Create swap
a. Understand what’s happening
Linux does not use swap just because RAM is being used.
It uses swap when:
- RAM pressure is high
- The kernel decides pages are “cold”
- The swappiness value allows it
So your system is probably sitting on RAM because it thinks:
“I still have breathing room, why suffer disk I/O?”
b. Check current swap
bash
swapon --show
free -hc. Create a 5GB swap file
This is the modern, correct, and easy way:
bash
sudo fallocate -l 5G /swapfileSecure the swap file:
bash
sudo chmod 600 /swapfileFormat it as swap:
bash
sudo mkswap /swapfileEnable it immediately:
bash
sudo swapon /swapfiled. Make it permanent
Edit /etc/fstab:
bash
sudo nano /etc/fstabAdd this line at the bottom:
fstab
/swapfile none swap sw 0 0e. Adjust swap behavior
Linux's default swappiness is a bit “eager” with swap. For servers, you usually want less swapping.
Check the current value:
bash
cat /proc/sys/vm/swappinessTemporarily set it:
bash
sudo sysctl vm.swappiness=10Make it permanent:
bash
echo "vm.swappiness=10" | sudo tee -a /etc/sysctl.confSome options:
| Goal | swappiness |
|---|---|
| Almost never swap | 10 |
| Balanced server (recommended) | 30–60 |
| Aggressively swap | 80–100 |
It’s helpful to restart to make sure everything was set up correctly.
bashsudo shutdown -r now
5. Set up firewalls
a. Install Cockpit
bash
sudo apt install ufw cockpit -yEnable Cockpit:
bash
sudo systemctl enable --now cockpit.socketNow open:
https://YOUR_SERVER_IP:9090
b. Configure firewall
NOTE:
After nginx is set up, close ports9090,81and9000.
Only keepSSH,HTTP, andHTTPSports open.
Default policy (block everything):
bash
sudo ufw default deny incoming
sudo ufw default allow outgoingAllow only what you need:
bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow 81/tcp # NGINX
sudo ufw allow 9000/tcp # Portainer
sudo ufw allow 9090/tcp # CockpitEnable UFW:
bash
sudo ufw enableCheck status:
bash
sudo ufw status verboseExample output:
bash
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
81/tcp ALLOW IN Anywhere
9000/tcp ALLOW IN Anywhere
9090/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)
81/tcp (v6) ALLOW IN Anywhere (v6)
9000/tcp (v6) ALLOW IN Anywhere (v6)
9090/tcp (v6) ALLOW IN Anywhere (v6) c. Let Cockpit control the firewall (Optional)
NOTE:
Having both UFW and firewalld active is like asking Linux to host a cage fight. Only have one enabled.
Check with:bashsudo ufw status sudo systemctl status firewalld
Disable UFW entirely and just use firewalld:
bash
ufw disableInstall and enable firewalld:
bash
sudo apt install firewalld -y
sudo systemctl enable --now firewalldEnable ports in firewalld:
bash
sudo firewall-cmd --permanent --add-port=81/tcp
sudo firewall-cmd --permanent --add-port=9000/tcp
sudo firewall-cmd --permanent --add-port=9090/tcp
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reloadNOTE:
You can find firewall configs in the web dashboard:
Cockpit → Networking → Firewall
6. Allow docker to access public internet
a. Run these to confirm current state
bash
systemctl status docker --no-pager -l
bash
docker info | sed -n '1,30p'
bash
sudo firewall-cmd --state
bash
sudo firewall-cmd --get-active-zonesb. Correct “safe Docker + firewalld” setup
bash
sudo firewall-cmd --permanent --zone=trusted --add-interface=docker0
bash
sudo firewall-cmd --permanent --add-masquerade
bash
sudo firewall-cmd --reloadc. Verify Docker NAT is correct
bash
sudo iptables -t nat -L -n -v | grep MASQUERADEYou should see Docker subnet (like 172.17.0.0/16 or 172.20.0.0/16).
d. Test real container internet
bash
docker run --rm alpine sh -c "apk add curl >/dev/null && curl -I https://google.com"checklist for any issues
Run in order:
Docker status
bashsystemctl status docker --no-pager -lReal error log
bashjournalctl -xeu docker.service --no-pager | tail -n 120Network state
bashdocker network lsNAT rules
bashsudo iptables -t nat -L -n -vFirewalld zones
bashfirewall-cmd --get-active-zones
7. Fix Docker not starting after apt upgrade / restart (ZONE_CONFLICT)
At least the error message tells you exactly what's wrong.
The error is:
ZONE_CONFLICT: 'docker0' already bound to 'trusted'Docker wants to place docker0 into its own docker firewalld zone, but firewalld already has docker0 assigned to the trusted zone, so Docker aborts.
Check first:
bash
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --get-zone-of-interface=docker0You'll almost certainly get:
trusted
interfaces: docker0Fix:
Remove docker0 from the trusted zone:
bash
sudo firewall-cmd --permanent --zone=trusted --remove-interface=docker0
sudo firewall-cmd --reloadThen restart Docker:
bash
sudo systemctl restart docker
sudo systemctl status docker